diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index ede5ecc..9fe1dbf 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -8,16 +8,19 @@ on: branches: - main +env: + DOTNET_VERSION: 6.0.119 + jobs: static_code_analysis: runs-on: ubuntu-20.04 steps: - name: Checkout repository and submodules uses: actions/checkout@v3 - - name: Install .NET 6.0.119 + - name: Install .NET ${{ env.DOTNET_VERSION }} uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.119 + dotnet-version: ${{ env.DOTNET_VERSION }} global-json-file: cloud_connectors/azure/digital_twins_connector/global.json - name: Cache NuGet dependencies uses: actions/cache@v3 @@ -30,7 +33,9 @@ jobs: - name: Build Digital Twins Connector run: ./cloud_connectors/azure/digital_twins_connector/build.sh - name: Build MQTT Connector's Azure Function - run: dotnet build cloud_connectors/azure/mqtt_connector/azure_function/src/function.csproj + run: | + dotnet build cloud_connectors/azure/mqtt_connector/azure_function/src/function.csproj -warnaserror + dotnet build cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.csproj -warnaserror - name: Digital Twins Connector Tests run: dotnet test cloud_connectors/azure/digital_twins_connector/tests/**/*.csproj - name: MQTT Connector's Azure Function Tests diff --git a/cloud_connectors/azure/.globalconfig b/cloud_connectors/azure/.globalconfig new file mode 100644 index 0000000..53317ae --- /dev/null +++ b/cloud_connectors/azure/.globalconfig @@ -0,0 +1,6 @@ +# Global analyzer config for .NET projects +is_global = true + +# The way to address this rule is not well documented and appears to be very complex, +# so it's left as a suggestion for now +dotnet_diagnostic.CA1848.severity = suggestion \ No newline at end of file diff --git a/cloud_connectors/azure/digital_twins_connector/build.sh b/cloud_connectors/azure/digital_twins_connector/build.sh index 9b1b69f..8566180 100755 --- a/cloud_connectors/azure/digital_twins_connector/build.sh +++ b/cloud_connectors/azure/digital_twins_connector/build.sh @@ -7,6 +7,6 @@ # Set the current directory to the directory of this script. cd "$(dirname "$0")" -dotnet build src/core/DigitalTwinsConnector.csproj -dotnet build src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.csproj -dotnet build tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.csproj \ No newline at end of file +dotnet build src/core/DigitalTwinsConnector.csproj -warnaserror +dotnet build src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.csproj -warnaserror +dotnet build tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.csproj -warnaserror \ No newline at end of file diff --git a/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.cs b/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.cs index 181814e..8a321c1 100644 --- a/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.cs +++ b/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.cs @@ -28,7 +28,7 @@ public class DigitalTwinsClientWrapper /// /// the path. /// Returns true if the path starts with a slash, otherwise false. - private bool DoesPathStartsWithSlash(string path) + private static bool DoesPathStartsWithSlash(string path) { return path.StartsWith('/'); } @@ -56,10 +56,12 @@ public DigitalTwinsClientWrapper(DigitalTwinsClient client, ILoggerthe digital twin instance ID. /// the property path of a digital twin instance to update. /// the data used to update a digital twin instance's property. + /// Rethrown if the client throws this exception + /// Thrown if the data parameter could not be parsed /// Returns a task for updating a digital twin instance. public async Task UpdateDigitalTwinAsync(string modelID, string instanceID, string instancePropertyPath, string data) { - List dataTypes = new List() { typeof(Double), typeof(Boolean), typeof(Int32) }; + List dataTypes = new() { typeof(double), typeof(bool), typeof(int) }; var jsonPatchDocument = new JsonPatchDocument(); foreach (Type type in dataTypes) @@ -73,19 +75,31 @@ public async Task UpdateDigitalTwinAsync(string modelID, string instanceID, stri { instancePropertyPath = "$/{instancePropertyPath}"; } + // Once we're able to parse the data string to a type // we append it to the jsonPatchDocument jsonPatchDocument.AppendAdd(instancePropertyPath, value); // First UpdateDigitalTwinAsync call may block due to initial authorization. await _client.UpdateDigitalTwinAsync(instanceID, jsonPatchDocument); - _logger.LogInformation($"Successfully set instance {instanceID}{instancePropertyPath} based on model {modelID} to {data}"); + _logger.LogInformation( + "Successfully set instance {InstanceID}{InstancePropertyPath} based on model {ModelID} to {Data}", + instanceID, + instancePropertyPath, + modelID, + data); return; } catch (RequestFailedException ex) { - _logger.LogError($"Cannot set instance {instanceID}{instancePropertyPath} based on model {modelID} to {data} due to {ex.Message}"); - throw ex; + _logger.LogError( + "Cannot set instance {InstanceID}{InstancePropertyPath} based on model {ModelID} to {Data} due to {Message}", + instanceID, + instancePropertyPath, + modelID, + data, + ex.Message); + throw; } // Try to parse string data with the next type if we're unsuccessful. catch (Exception ex) when (ex is NotSupportedException || ex is ArgumentException || ex is FormatException) @@ -94,9 +108,13 @@ public async Task UpdateDigitalTwinAsync(string modelID, string instanceID, stri } } - string errorMessage = $"Failed to parse {data}. Cannot set instance {instanceID}{instancePropertyPath} based on model {modelID} to {data}"; - _logger.LogError(errorMessage); - throw new NotSupportedException(errorMessage); + _logger.LogError( + "Failed to parse data. Cannot set instance {InstanceID}{InstancePropertyPath} based on model {ModelID} to {Data}", + instanceID, + instancePropertyPath, + modelID, + data); + throw new NotSupportedException(); } } } diff --git a/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.csproj b/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.csproj index 6f27d36..4242c4d 100644 --- a/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.csproj +++ b/cloud_connectors/azure/digital_twins_connector/src/DigitalTwinsClientWrapper/DigitalTwinsClientWrapper.csproj @@ -3,6 +3,7 @@ net6.0 enable + latest-recommended diff --git a/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnector.csproj b/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnector.csproj index 88cae81..6ba07ce 100644 --- a/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnector.csproj +++ b/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnector.csproj @@ -4,6 +4,7 @@ Exe net6.0 enable + latest-recommended diff --git a/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnectorService.cs b/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnectorService.cs index ae6882f..696f7ad 100644 --- a/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnectorService.cs +++ b/cloud_connectors/azure/digital_twins_connector/src/core/DigitalTwinsConnectorService.cs @@ -43,7 +43,7 @@ public override async Task UpdateDigitalTwin(UpdateDi } catch (Exception ex) { - _logger.LogError(ex.Message); + _logger.LogError("Error updating digital twin: {ExceptionType}: {Message}", ex.GetType(), ex.Message); throw; } diff --git a/cloud_connectors/azure/digital_twins_connector/src/core/Program.cs b/cloud_connectors/azure/digital_twins_connector/src/core/Program.cs index 695692e..aabd46e 100644 --- a/cloud_connectors/azure/digital_twins_connector/src/core/Program.cs +++ b/cloud_connectors/azure/digital_twins_connector/src/core/Program.cs @@ -29,13 +29,14 @@ static void Main(string[] args) string adtInstanceUrl = adtInstanceConfig.AzureDigitalTwinsInstanceUrl; var credential = new DefaultAzureCredential(); - DigitalTwinsClient client = new DigitalTwinsClient(new Uri(adtInstanceUrl), credential); + DigitalTwinsClient client = new(new Uri(adtInstanceUrl), credential); ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(c => { c.TimestampFormat = "[yyyy-MM-ddTHH:mm::ssZ] "; c.UseUtcTimestamp = true; })); + loggerFactory.CreateLogger("Main").LogInformation("Started the Azure Digital Twins Connector"); // Instantiate the DigitalTwinClient first before adding it as a service for dependency injection. diff --git a/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.cs b/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.cs index 09be11f..bfe43bf 100644 --- a/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.cs +++ b/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.cs @@ -23,7 +23,7 @@ public void Setup() } [Test] - public async Task UpdateDigitalTwinAsync_ShouldSucceed() + public async Task UpdateDigitalTwinAsyncShouldSucceed() { const string modelID = "some-model"; const string instanceID = "some-instance"; @@ -33,7 +33,7 @@ public async Task UpdateDigitalTwinAsync_ShouldSucceed() } [Test] - public void UpdateDigitalTwinAsync_ThrowNotSupported() + public void UpdateDigitalTwinAsyncThrowNotSupported() { const string modelID = "some-model"; const string instanceID = "some-instance"; diff --git a/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.csproj b/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.csproj index b0409a1..20b3fec 100644 --- a/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.csproj +++ b/cloud_connectors/azure/digital_twins_connector/tests/DigitalTwinsClientWrapper.Tests/DigitalTwinsClientWrapper.Tests.csproj @@ -4,6 +4,7 @@ net6.0 enable false + latest-recommended diff --git a/cloud_connectors/azure/mqtt_connector/azure_function/src/function.csproj b/cloud_connectors/azure/mqtt_connector/azure_function/src/function.csproj index 9f0674c..323b768 100644 --- a/cloud_connectors/azure/mqtt_connector/azure_function/src/function.csproj +++ b/cloud_connectors/azure/mqtt_connector/azure_function/src/function.csproj @@ -2,6 +2,7 @@ net6.0 v4 + latest-recommended diff --git a/cloud_connectors/azure/mqtt_connector/azure_function/src/run.cs b/cloud_connectors/azure/mqtt_connector/azure_function/src/run.cs index 3fe8523..c2b54fe 100644 --- a/cloud_connectors/azure/mqtt_connector/azure_function/src/run.cs +++ b/cloud_connectors/azure/mqtt_connector/azure_function/src/run.cs @@ -4,7 +4,8 @@ using System; using System.Collections.Generic; -using System.ComponentModel; +using System.Globalization; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Azure; @@ -19,25 +20,63 @@ namespace Microsoft.ESDV.CloudConnector.Azure { /// /// This class contains the info to target an Azure Digital Twin instance. /// - public class DigitalTwinsInstance { - public string model_id { get; set; } - public string instance_id { get; set; } - public string instance_property_path { get; set; } - public string data { get; set; } + public class DigitalTwinsInstance + { + /// + /// The Azure Digital Twins model ID + /// + [JsonPropertyName("model_id")] + public string ModelId { get; set; } + + /// + /// The Azure Digital Twins instance ID + /// + [JsonPropertyName("instance_id")] + public string InstanceId { get; set; } + + /// + /// The Azure Digital Twins instance property path + /// + [JsonPropertyName("instance_property_path")] + public string InstancePropertyPath { get; set; } + + /// + /// The data to synchronize + /// + [JsonPropertyName("data")] + public string Data { get; set; } } - public class MQTTConnectorAzureFunction { + /// + /// Azure function for use with the MQTT connector. + /// Reads data from an event grid and forwards it to Azure Digital Twins. + /// + public class MQTTConnectorAzureFunction + { + /// + /// The logger for this function + /// private readonly ILogger _logger; + /// + /// The environment variable name for the keyvault settings + /// private const string KEYVAULT_SETTINGS = "KEYVAULT_SETTINGS"; - // Maps a string data type name to its concrete data type. - private static readonly Dictionary dataTypeNameToConverterMap = new Dictionary { + /// + /// Maps a string data type name to its concrete data type. + /// + private static readonly Dictionary dataTypeNameToConverterMap = new() + { { "int", typeof(int) }, { "double", typeof(double) }, { "boolean", typeof(bool) } }; + /// + /// Create a new MQTTConnectorAzureFunction + /// + /// The logger to use public MQTTConnectorAzureFunction(ILogger logger) { _logger = logger; @@ -48,20 +87,25 @@ public MQTTConnectorAzureFunction(ILogger logger) /// /// the path. /// Returns true if the path starts with a slash, otherwise false. - public static bool DoesPathStartsWithSlash(string path) { + public static bool DoesPathStartsWithSlash(string path) + { return path.StartsWith('/'); } /// /// Gets the data type from a data type name. /// - /// the name of the data type. + /// the name of the data type. + /// Thrown if the data type is not supported. /// Returns a task for updating a digital twin instance. - public Type GetDataTypeFromString(string dataTypeName) { - if (!dataTypeNameToConverterMap.ContainsKey(dataTypeName)) { + public static Type GetDataTypeFromString(string dataTypeName) + { + if (!dataTypeNameToConverterMap.TryGetValue(dataTypeName, out Type value)) + { throw new NotSupportedException($"No conversion for {dataTypeName}"); } - return dataTypeNameToConverterMap[dataTypeName]; + + return value; } /// @@ -69,33 +113,38 @@ public Type GetDataTypeFromString(string dataTypeName) { /// /// the Azure Digital Twins client. /// the digital twin instance to update. - /// the name of the data type. + /// the name of the data type. Defaults to "double". /// Returns a task for updating a digital twin instance. - public async Task UpdateDigitalTwinAsync(DigitalTwinsClient client, DigitalTwinsInstance instance, string dataTypeName = "double") { - JsonPatchDocument jsonPatchDocument = new JsonPatchDocument(); + public static async Task UpdateDigitalTwinAsync(DigitalTwinsClient client, DigitalTwinsInstance instance, string dataTypeName = "double") + { + JsonPatchDocument jsonPatchDocument = new(); - try { + try + { // Get the concrete data type of an instance's data based on its string data type name // then uses that concrete data type to change the data from string to its concrete data type. Type dataType = GetDataTypeFromString(dataTypeName); - dynamic convertedDataToType = Convert.ChangeType(instance.data, dataType); + dynamic convertedDataToType = Convert.ChangeType(instance.Data, dataType, CultureInfo.InvariantCulture); - if (!DoesPathStartsWithSlash(instance.instance_property_path)) + if (!DoesPathStartsWithSlash(instance.InstancePropertyPath)) { - instance.instance_property_path = $"/{instance.instance_property_path}"; + instance.InstancePropertyPath = $"/{instance.InstancePropertyPath}"; } - jsonPatchDocument.AppendAdd(instance.instance_property_path, convertedDataToType); + jsonPatchDocument.AppendAdd(instance.InstancePropertyPath, convertedDataToType); } - catch (Exception ex) when (ex is NotSupportedException || ex is InvalidCastException || ex is FormatException) { - throw new NotSupportedException($"Cannot convert {instance.data}. {ex.Message}"); + catch (Exception ex) when (ex is NotSupportedException || ex is InvalidCastException || ex is FormatException) + { + throw new NotSupportedException($"Cannot convert {instance.Data}. {ex.Message}"); } - try { - await client.UpdateDigitalTwinAsync(instance.instance_id, jsonPatchDocument); + try + { + await client.UpdateDigitalTwinAsync(instance.InstanceId, jsonPatchDocument); } - catch(RequestFailedException ex) { - string errorMessage = @$"Cannot set instance {instance.instance_id}{instance.instance_property_path} - based on model {instance.model_id} to {instance.data} due to {ex.Message}"; + catch(RequestFailedException ex) + { + string errorMessage = @$"Cannot set instance {instance.InstanceId}{instance.InstancePropertyPath} + based on model {instance.ModelId} to {instance.Data} due to {ex.Message}"; throw new NotSupportedException(errorMessage); } } @@ -108,18 +157,21 @@ public async Task UpdateDigitalTwinAsync(DigitalTwinsClient client, DigitalTwins /// An exception is thrown if the Azure Digital Twin client cannot update an instance. /// [FunctionName("MQTTConnectorAzureFunction")] - public async Task Run([EventGridTrigger] CloudEvent cloudEvent) { + public async Task Run([EventGridTrigger] CloudEvent cloudEvent) + { DigitalTwinsInstance instance = cloudEvent.Data.ToObjectFromJson(); - try { - DefaultAzureCredential credential = new DefaultAzureCredential(); + try + { + DefaultAzureCredential credential = new(); string adt_instance_url = Environment.GetEnvironmentVariable(KEYVAULT_SETTINGS, EnvironmentVariableTarget.Process); - DigitalTwinsClient client = new DigitalTwinsClient(new Uri(adt_instance_url), credential); + DigitalTwinsClient client = new(new Uri(adt_instance_url), credential); await UpdateDigitalTwinAsync(client, instance); - _logger.LogInformation(@$"Successfully set instance {instance.instance_id}{instance.instance_property_path} - based on model {instance.model_id} to {instance.data}"); + _logger.LogInformation(@$"Successfully set instance {instance.InstanceId}{instance.InstancePropertyPath} + based on model {instance.ModelId} to {instance.Data}"); } - catch (Exception ex) { + catch (Exception ex) + { _logger.LogError(ex.Message); throw; } diff --git a/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.cs b/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.cs index 19bde42..5521487 100644 --- a/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.cs +++ b/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // SPDX-License-Identifier: MIT +using System.Text.Json; using Azure.DigitalTwins.Core; using Microsoft.Extensions.Logging; using Moq; @@ -22,49 +23,67 @@ public void Setup() _connector = new MQTTConnectorAzureFunction(new Mock>().Object); _instance = new DigitalTwinsInstance { - model_id = "some-model", - instance_id = "some-instance", - instance_property_path = "some-instance-property", - data = null + ModelId = "some-model", + InstanceId = "some-instance", + InstancePropertyPath = "some-instance-property", + Data = null }; } [Test] - public void ConvertStringToDataType_ShouldSucceed() + public void ConvertStringToDataTypeShouldSucceed() { - Assert.That(_connector.GetDataTypeFromString("int"), Is.EqualTo(typeof(int))); - Assert.That(_connector.GetDataTypeFromString("double"), Is.EqualTo(typeof(double))); - Assert.That(_connector.GetDataTypeFromString("boolean"), Is.EqualTo(typeof(bool))); - Assert.Throws(() => _connector.GetDataTypeFromString("invalid-converter")); + Assert.Multiple(() => + { + Assert.That(MQTTConnectorAzureFunction.GetDataTypeFromString("int"), Is.EqualTo(typeof(int))); + Assert.That(MQTTConnectorAzureFunction.GetDataTypeFromString("double"), Is.EqualTo(typeof(double))); + Assert.That(MQTTConnectorAzureFunction.GetDataTypeFromString("boolean"), Is.EqualTo(typeof(bool))); + }); + Assert.Throws(() => MQTTConnectorAzureFunction.GetDataTypeFromString("invalid-converter")); } [Test] - public async Task UpdateDigitalTwinAsync_ShouldSucceed() + public async Task UpdateDigitalTwinAsyncShouldSucceed() { - _instance.data = "44.5"; - await _connector.UpdateDigitalTwinAsync(_client, _instance, "double"); + _instance.Data = "44.5"; + await MQTTConnectorAzureFunction.UpdateDigitalTwinAsync(_client, _instance, "double"); Assert.Pass(); - _instance.data = "44"; - await _connector.UpdateDigitalTwinAsync(_client, _instance, "int"); + _instance.Data = "44"; + await MQTTConnectorAzureFunction.UpdateDigitalTwinAsync(_client, _instance, "int"); Assert.Pass(); - _instance.data = "true"; - await _connector.UpdateDigitalTwinAsync(_client, _instance, "boolean"); + _instance.Data = "true"; + await MQTTConnectorAzureFunction.UpdateDigitalTwinAsync(_client, _instance, "boolean"); Assert.Pass(); } [Test] - public void UpdateDigitalTwinAsync_ThrowNotSupported() + public void UpdateDigitalTwinAsyncThrowNotSupported() + { + _instance.Data = null; + Assert.ThrowsAsync(async () => await MQTTConnectorAzureFunction.UpdateDigitalTwinAsync(_client, _instance)); + + _instance.Data = "test1234"; + Assert.ThrowsAsync(async () => await MQTTConnectorAzureFunction.UpdateDigitalTwinAsync(_client, _instance, "invalid-converter")); + + _instance.Data = ""; + Assert.ThrowsAsync(async () => await MQTTConnectorAzureFunction.UpdateDigitalTwinAsync(_client, _instance, "double")); + } + + [Test] + public void CanDeserializeDigitalTwinsInstance() { - _instance.data = null; - Assert.ThrowsAsync(async () => await _connector.UpdateDigitalTwinAsync(_client, _instance)); + string input = @"{ + ""model_id"": ""some-model"", + ""instance_id"": ""some-instance"", + ""instance_property_path"": ""some-instance-property"", + ""data"": ""42"" + }"; - _instance.data = "test1234"; - Assert.ThrowsAsync(async () => await _connector.UpdateDigitalTwinAsync(_client, _instance, "invalid-converter")); + BinaryData data = BinaryData.FromString(input); - _instance.data = ""; - Assert.ThrowsAsync(async () => await _connector.UpdateDigitalTwinAsync(_client, _instance, "double")); + Assert.DoesNotThrow(() => data.ToObjectFromJson()); } } } diff --git a/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.csproj b/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.csproj index 24c4e2d..ac71991 100644 --- a/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.csproj +++ b/cloud_connectors/azure/mqtt_connector/azure_function/tests/MQTTConnectorAzureFunction.Tests.csproj @@ -4,6 +4,7 @@ net6.0 enable false + latest-recommended