diff --git a/README.md b/README.md index 6b1246fbf..9fd3cb0d2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Azure SQL bindings for Azure Functions are supported for: - [Stored Procedure](#stored-procedure) - [IAsyncEnumerable](#iasyncenumerable) - [Output Binding](#output-binding) - - [ICollector/IAsyncCollector](#icollectortiasynccollectort) + - [ICollector<T>/IAsyncCollector<T>](#icollectortiasynccollectort) - [Array](#array) - [Single Row](#single-row) - [Primary Keys and Identity Columns](#primary-keys-and-identity-columns) @@ -240,7 +240,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app that you created in [Create a Function App](#create-a-function-app) in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> Company.namespace -> anonymous -- In the file that opens, replace the 'public static async Task< IActionResult > Run' block with the below code. +- In the file that opens, replace the `public static async Task Run` block with the below code. ```csharp public static async Task Run( @@ -288,7 +288,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> Company.namespace is fine -> anonymous -- In the file that opens, replace the 'public static async Task Run' block with the below code +- In the file that opens, replace the `public static async Task Run` block with the below code ```csharp public static IActionResult Run( @@ -377,7 +377,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app that you created in [Create a Function App](#create-a-function-app) in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (index.js), replace the 'module.exports = async function (context, req)' block with the below code. +- In the file that opens (`index.js`), replace the `module.exports = async function (context, req)` block with the below code. ```javascript module.exports = async function (context, req, employee) { @@ -417,7 +417,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (index.js), replace the 'module.exports = async function (context, req)' block with the below code. +- In the file that opens (`index.js`), replace the `module.exports = async function (context, req)` block with the below code. ```javascript module.exports = async function (context, req) { @@ -472,7 +472,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app that you created in [Create a Function App](#create-a-function-app) in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (__init__.py), replace the 'def main(req: func.HttpRequest) -> func.HttpResponse:' block with the below code. +- In the file that opens (`__init__.py`), replace the `def main(req: func.HttpRequest) -> func.HttpResponse:` block with the below code. ```python def main(req: func.HttpRequest, employee: func.SqlRowList) -> func.HttpResponse: @@ -515,7 +515,7 @@ Note: This tutorial requires that a SQL database is setup as shown in [Create a - Open your app in VSCode - Press 'F1' and search for 'Azure Functions: Create Function' - Choose HttpTrigger -> (Provide a function name) -> anonymous -- In the file that opens (__init__.py), replace the 'def main(req: func.HttpRequest) -> func.HttpResponse:' block with the below code. +- In the file that opens (`__init__.py`), replace the `def main(req: func.HttpRequest) -> func.HttpResponse:` block with the below code. ```python def main(req: func.HttpRequest, employee: func.Out[func.SqlRow]) -> func.HttpResponse: @@ -567,8 +567,8 @@ The input binding takes four [arguments](https://github.com/Azure/azure-function The following are valid binding types for the result of the query/stored procedure execution: -- **IEnumerable**: Each element is a row of the result represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) section for an example of what `T` should look like. -- **IAsyncEnumerable**: Each element is again a row of the result represented by `T`, but the rows are retrieved "lazily". A row of the result is only retrieved when `MoveNextAsync` is called on the enumerator. This is useful in the case that the query can return a very large amount of rows. +- **IEnumerable<T>**: Each element is a row of the result represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) section for an example of what `T` should look like. +- **IAsyncEnumerable<T>**: Each element is again a row of the result represented by `T`, but the rows are retrieved "lazily". A row of the result is only retrieved when `MoveNextAsync` is called on the enumerator. This is useful in the case that the query can return a very large amount of rows. - **String**: A JSON string representation of the rows of the result (an example is provided [here](https://github.com/Azure/azure-functions-sql-extension/blob/main/samples/samples-csharp/InputBindingSamples/GetProductsString.cs)). - **SqlCommand**: The SqlCommand is populated with the appropriate query and parameters, but the associated connection is not opened. It is the responsiblity of the user to execute the command and read in the results. This is useful in the case that the user wants more control over how the results are read in. An example is provided [here](https://github.com/Azure/azure-functions-sql-extension/blob/main/samples/samples-csharp/InputBindingSamples/GetProductsSqlCommand.cs). @@ -708,13 +708,13 @@ The output binding takes two [arguments](https://github.com/Azure/azure-function The following are valid binding types for the rows to be upserted into the table: -- **ICollector/IAsyncCollector**: Each element is a row represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) for an example of what `T` should look like. +- **ICollector<T>/IAsyncCollector<T>**: Each element is a row represented by `T`, where `T` is a user-defined POCO, or Plain Old C# Object. `T` should follow the structure of a row in the queried table. See the [Query String](#query-string) for an example of what `T` should look like. - **T**: Used when just one row is to be upserted into the table. - **T[]**: Each element is again a row of the result represented by `T`. This output binding type requires manual instantiation of the array in the function. The repo contains examples of each of these binding types [here](https://github.com/Azure/azure-functions-sql-extension/tree/main/samples/samples-csharp/OutputBindingSamples). A few examples are also included below. -#### ICollector/IAsyncCollector +#### ICollector<T>/IAsyncCollector<T> When using an `ICollector`, it is not necessary to instantiate it. The function can add rows to the `ICollector` directly, and its contents are automatically upserted once the function exits. diff --git a/builds/TSAConfig.gdntsa b/builds/TSAConfig.gdntsa index 56186440c..11b8aeb1b 100644 --- a/builds/TSAConfig.gdntsa +++ b/builds/TSAConfig.gdntsa @@ -1,7 +1,7 @@ { "codebaseName": "Sql Bindings", "notificationAliases": [ - "sqltools@service.microsoft.com" + "sqlbindings@microsoft.com" ], "codebaseAdmins": [ "REDMOND\\chlafren", @@ -14,6 +14,7 @@ "tools": [ "BinSkim", "RoslynAnalyzers", - "CredScan" + "CredScan", + "Policheck" ] } \ No newline at end of file diff --git a/builds/azure-pipelines/template-steps-build-test.yml b/builds/azure-pipelines/template-steps-build-test.yml index 050f244ef..40b6b9165 100644 --- a/builds/azure-pipelines/template-steps-build-test.yml +++ b/builds/azure-pipelines/template-steps-build-test.yml @@ -12,6 +12,14 @@ steps: inputs: useGlobalJson: true +# Run Policheck early to avoid scanning dependency folders +- task: securedevelopmentteam.vss-secure-development-tools.build-task-policheck.PoliCheck@2 + displayName: 'Run PoliCheck' + inputs: + targetType: F + result: PoliCheck.xml + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + - script: npm install -g azure-functions-core-tools displayName: 'Install Azure Functions Core Tools' @@ -84,12 +92,13 @@ steps: arguments: 'analyze $(Build.SourcesDirectory)\src\bin\${{ parameters.configuration }}\* --recurse --verbose' condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) +# Don't run for PRs since this currently breaks on runs from forks. We run this daily ourselves anyways. - task: securedevelopmentteam.vss-secure-development-tools.build-task-roslynanalyzers.RoslynAnalyzers@3 inputs: userProvideBuildInfo: 'autoMsBuildInfo' env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), ne(variables['Build.Reason'], 'PullRequest')) - task: securedevelopmentteam.vss-secure-development-tools.build-task-credscan.CredScan@2 inputs: @@ -135,7 +144,7 @@ steps: inputs: GdnPublishTsaOnboard: true GdnPublishTsaConfigFile: '$(Build.SourcesDirectory)\builds\TSAConfig.gdntsa' - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), ne(variables['Build.Reason'], 'PullRequest')) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['TSA_UPLOAD'], 'true')) # 5.0 isn't supported on Mac yet - task: UseDotNet@2 @@ -253,3 +262,9 @@ steps: displayName: 'Component Detection' inputs: failOnAlert: true + +- task: securedevelopmentteam.vss-secure-development-tools.build-task-postanalysis.PostAnalysis@2 + displayName: 'Post Analysis' + inputs: + GdnBreakPolicyMinSev: Error + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) diff --git a/src/SqlAsyncCollector.cs b/src/SqlAsyncCollector.cs index 05d2f77b9..238ff89e8 100644 --- a/src/SqlAsyncCollector.cs +++ b/src/SqlAsyncCollector.cs @@ -35,6 +35,11 @@ public PrimaryKey(string name, bool isIdentity) this.Name = name; this.IsIdentity = isIdentity; } + + public override string ToString() + { + return this.Name; + } } /// A user-defined POCO that represents a row of the user's table @@ -305,7 +310,7 @@ private static void GenerateDataQueryForMerge(TableInformation table, IEnumerabl { // SQL Server allows 900 bytes per primary key, so use that as a baseline var combinedPrimaryKey = new StringBuilder(900 * table.PrimaryKeys.Count()); - // Look up primary key of T. Because we're going in the same order of fields every time, + // Look up primary key of T. Because we're going in the same order of properties every time, // we can assume that if two rows with the same primary key are in the list, they will collide foreach (PropertyInfo primaryKey in table.PrimaryKeys) { @@ -622,9 +627,9 @@ public static async Task RetrieveTableInformationAsync(SqlConn throw ex; } - // Match SQL Primary Key column names to POCO field/property objects. Ensure none are missing. + // Match SQL Primary Key column names to POCO property objects. Ensure none are missing. StringComparison comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - IEnumerable primaryKeyFields = typeof(T).GetProperties().Where(f => primaryKeys.Any(k => string.Equals(k.Name, f.Name, comparison))); + IEnumerable primaryKeyProperties = typeof(T).GetProperties().Where(f => primaryKeys.Any(k => string.Equals(k.Name, f.Name, comparison))); IEnumerable primaryKeysFromObject = columnNames.Where(f => primaryKeys.Any(k => string.Equals(k.Name, f, comparison))); IEnumerable missingPrimaryKeysFromItem = primaryKeys .Where(k => !primaryKeysFromObject.Contains(k.Name, comparer)); @@ -654,8 +659,8 @@ public static async Task RetrieveTableInformationAsync(SqlConn sqlConnProps.Add(TelemetryPropertyName.QueryType, usingInsertQuery ? "insert" : "merge"); sqlConnProps.Add(TelemetryPropertyName.HasIdentityColumn, hasIdentityColumnPrimaryKeys.ToString()); TelemetryInstance.TrackDuration(TelemetryEventName.GetTableInfoEnd, tableInfoSw.ElapsedMilliseconds, sqlConnProps, durations); - logger.LogDebugWithThreadId($"END RetrieveTableInformationAsync Duration={tableInfoSw.ElapsedMilliseconds}ms DB and Table: {sqlConnection.Database}.{fullName}. Primary keys: [{string.Join(",", primaryKeyFields.Select(pk => pk.Name))}]. SQL Column and Definitions: [{string.Join(",", columnDefinitionsFromSQL)}]"); - return new TableInformation(primaryKeyFields, columnDefinitionsFromSQL, comparer, query, hasIdentityColumnPrimaryKeys); + logger.LogDebugWithThreadId($"END RetrieveTableInformationAsync Duration={tableInfoSw.ElapsedMilliseconds}ms DB and Table: {sqlConnection.Database}.{fullName}. Primary keys: [{string.Join(",", primaryKeyProperties.Select(pk => pk.Name))}]. SQL Column and Definitions: [{string.Join(",", columnDefinitionsFromSQL)}]"); + return new TableInformation(primaryKeyProperties, columnDefinitionsFromSQL, comparer, query, hasIdentityColumnPrimaryKeys); } } diff --git a/src/SqlAsyncEnumerable.cs b/src/SqlAsyncEnumerable.cs index 86519f39a..9b10eab81 100644 --- a/src/SqlAsyncEnumerable.cs +++ b/src/SqlAsyncEnumerable.cs @@ -7,13 +7,12 @@ using System.Threading.Tasks; using Microsoft.Data.SqlClient; using Newtonsoft.Json; - namespace Microsoft.Azure.WebJobs.Extensions.Sql { /// A user-defined POCO that represents a row of the user's table internal class SqlAsyncEnumerable : IAsyncEnumerable { - private readonly SqlConnection _connection; + public SqlConnection Connection { get; private set; } private readonly SqlAttribute _attribute; /// @@ -26,8 +25,9 @@ internal class SqlAsyncEnumerable : IAsyncEnumerable /// public SqlAsyncEnumerable(SqlConnection connection, SqlAttribute attribute) { - this._connection = connection ?? throw new ArgumentNullException(nameof(connection)); + this.Connection = connection ?? throw new ArgumentNullException(nameof(connection)); this._attribute = attribute ?? throw new ArgumentNullException(nameof(attribute)); + this.Connection.Open(); } /// /// Returns the enumerator associated with this enumerable. The enumerator will execute the query specified @@ -38,7 +38,7 @@ public SqlAsyncEnumerable(SqlConnection connection, SqlAttribute attribute) /// The enumerator public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - return new SqlAsyncEnumerator(this._connection, this._attribute); + return new SqlAsyncEnumerator(this.Connection, this._attribute); } @@ -47,7 +47,6 @@ private class SqlAsyncEnumerator : IAsyncEnumerator private readonly SqlConnection _connection; private readonly SqlAttribute _attribute; private SqlDataReader _reader; - /// /// Initializes a new instance of the "/> class. /// @@ -77,7 +76,7 @@ public SqlAsyncEnumerator(SqlConnection connection, SqlAttribute attribute) public ValueTask DisposeAsync() { // Doesn't seem like there's an async version of closing the reader/connection - this._reader.Close(); + this._reader?.Close(); this._connection.Close(); return new ValueTask(Task.CompletedTask); } @@ -101,23 +100,24 @@ public ValueTask MoveNextAsync() /// private async Task GetNextRowAsync() { - if (this._reader == null) + // check connection state before trying to access the reader + // if DisposeAsync has already closed it due to the issue described here https://github.com/Azure/azure-functions-sql-extension/issues/350 + if (this._connection.State != System.Data.ConnectionState.Closed) { - using (SqlCommand command = SqlBindingUtilities.BuildCommand(this._attribute, this._connection)) + if (this._reader == null) { - await command.Connection.OpenAsync(); - this._reader = await command.ExecuteReaderAsync(); + using (SqlCommand command = SqlBindingUtilities.BuildCommand(this._attribute, this._connection)) + { + this._reader = await command.ExecuteReaderAsync(); + } + } + if (await this._reader.ReadAsync()) + { + this.Current = JsonConvert.DeserializeObject(this.SerializeRow()); + return true; } } - if (await this._reader.ReadAsync()) - { - this.Current = JsonConvert.DeserializeObject(this.SerializeRow()); - return true; - } - else - { - return false; - } + return false; } /// diff --git a/src/SqlConverters.cs b/src/SqlConverters.cs index 3b58f1e3e..628647dfa 100644 --- a/src/SqlConverters.cs +++ b/src/SqlConverters.cs @@ -105,12 +105,11 @@ public SqlGenericsConverter(IConfiguration configuration, ILogger logger) /// An IEnumerable containing the rows read from the user's database in the form of the user-defined POCO public async Task> ConvertAsync(SqlAttribute attribute, CancellationToken cancellationToken) { - TelemetryInstance.TrackConvert(ConvertType.IEnumerable); this._logger.LogDebugWithThreadId("BEGIN ConvertAsync (IEnumerable)"); var sw = Stopwatch.StartNew(); try { - string json = await this.BuildItemFromAttributeAsync(attribute); + string json = await this.BuildItemFromAttributeAsync(attribute, ConvertType.IEnumerable); IEnumerable result = JsonConvert.DeserializeObject>(json); this._logger.LogDebugWithThreadId($"END ConvertAsync (IEnumerable) Duration={sw.ElapsedMilliseconds}ms"); return result; @@ -140,12 +139,11 @@ public async Task> ConvertAsync(SqlAttribute attribute, Cancellat /// async Task IAsyncConverter.ConvertAsync(SqlAttribute attribute, CancellationToken cancellationToken) { - TelemetryInstance.TrackConvert(ConvertType.Json); this._logger.LogDebugWithThreadId("BEGIN ConvertAsync (Json)"); var sw = Stopwatch.StartNew(); try { - string result = await this.BuildItemFromAttributeAsync(attribute); + string result = await this.BuildItemFromAttributeAsync(attribute, ConvertType.Json); this._logger.LogDebugWithThreadId($"END ConvertAsync (Json) Duration={sw.ElapsedMilliseconds}ms"); return result; } @@ -167,8 +165,11 @@ async Task IAsyncConverter.ConvertAsync(SqlAttribu /// /// The binding attribute that contains the name of the connection string app setting and query. /// + /// + /// The type of conversion being performed by the input binding. + /// /// - public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attribute) + public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attribute, ConvertType type) { using (SqlConnection connection = SqlBindingUtilities.BuildConnection(attribute.ConnectionStringSetting, this._configuration)) // Ideally, we would like to move away from using SqlDataAdapter both here and in the @@ -178,6 +179,8 @@ public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attri { adapter.SelectCommand = command; await connection.OpenAsync(); + Dictionary props = connection.AsConnectionProps(); + TelemetryInstance.TrackConvert(type, props); var dataTable = new DataTable(); adapter.Fill(dataTable); this._logger.LogInformation($"{dataTable.Rows.Count} row(s) queried from database: {connection.Database} using Command: {command.CommandText}"); @@ -188,10 +191,12 @@ public virtual async Task BuildItemFromAttributeAsync(SqlAttribute attri IAsyncEnumerable IConverter>.Convert(SqlAttribute attribute) { - TelemetryInstance.TrackConvert(ConvertType.IAsyncEnumerable); try { - return new SqlAsyncEnumerable(SqlBindingUtilities.BuildConnection(attribute.ConnectionStringSetting, this._configuration), attribute); + var asyncEnumerable = new SqlAsyncEnumerable(SqlBindingUtilities.BuildConnection(attribute.ConnectionStringSetting, this._configuration), attribute); + Dictionary props = asyncEnumerable.Connection.AsConnectionProps(); + TelemetryInstance.TrackConvert(ConvertType.IAsyncEnumerable, props); + return asyncEnumerable; } catch (Exception ex) { @@ -214,10 +219,9 @@ IAsyncEnumerable IConverter>.Convert(SqlAtt /// JArray containing the rows read from the user's database in the form of the user-defined POCO async Task IAsyncConverter.ConvertAsync(SqlAttribute attribute, CancellationToken cancellationToken) { - TelemetryInstance.TrackConvert(ConvertType.JArray); try { - string json = await this.BuildItemFromAttributeAsync(attribute); + string json = await this.BuildItemFromAttributeAsync(attribute, ConvertType.JArray); return JArray.Parse(json); } catch (Exception ex) diff --git a/test/Unit/SqlInputBindingTests.cs b/test/Unit/SqlInputBindingTests.cs index 482ad2ef9..8da337d07 100644 --- a/test/Unit/SqlInputBindingTests.cs +++ b/test/Unit/SqlInputBindingTests.cs @@ -13,6 +13,7 @@ using Moq; using Xunit; using Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Common; +using Microsoft.Azure.WebJobs.Extensions.Sql.Telemetry; namespace Microsoft.Azure.WebJobs.Extensions.Sql.Tests.Unit { @@ -63,17 +64,18 @@ public void TestNullCommand() [Fact] public void TestNullArgumentsSqlAsyncEnumerableConstructor() { - Assert.Throws(() => new SqlAsyncEnumerable(connection, null)); Assert.Throws(() => new SqlAsyncEnumerable(null, new SqlAttribute(""))); } + /// + /// SqlAsyncEnumerable should throw InvalidOperationExcepion when invoked with an invalid connection + /// string setting and It should fail here since we're passing an empty connection string. + /// [Fact] - public void TestNullCurrentValueEnumerator() + public void TestInvalidOperationSqlAsyncEnumerableConstructor() { - var enumerable = new SqlAsyncEnumerable(connection, new SqlAttribute("")); - IAsyncEnumerator enumerator = enumerable.GetAsyncEnumerator(); - Assert.Null(enumerator.Current); + Assert.Throws(() => new SqlAsyncEnumerable(connection, new SqlAttribute(""))); } [Fact] @@ -233,7 +235,7 @@ public async void TestWellformedDeserialization() var converter = new Mock>(config.Object, logger.Object); string json = "[{ \"ID\":1,\"Name\":\"Broom\",\"Cost\":32.5,\"Timestamp\":\"2019-11-22T06:32:15\"},{ \"ID\":2,\"Name\":\"Brush\",\"Cost\":12.3," + "\"Timestamp\":\"2017-01-27T03:13:11\"},{ \"ID\":3,\"Name\":\"Comb\",\"Cost\":100.12,\"Timestamp\":\"1997-05-03T10:11:56\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); var list = new List(); var data1 = new TestData { @@ -271,7 +273,7 @@ public async void TestMalformedDeserialization() // SQL data is missing a field string json = "[{ \"ID\":1,\"Name\":\"Broom\",\"Timestamp\":\"2019-11-22T06:32:15\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); var list = new List(); var data = new TestData { @@ -286,7 +288,7 @@ public async void TestMalformedDeserialization() // SQL data's columns are named differently than the POCO's fields json = "[{ \"ID\":1,\"Product Name\":\"Broom\",\"Price\":32.5,\"Timessstamp\":\"2019-11-22T06:32:15\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); list = new List(); data = new TestData { @@ -300,7 +302,7 @@ public async void TestMalformedDeserialization() // Confirm that the JSON fields are case-insensitive (technically malformed string, but still works) json = "[{ \"id\":1,\"nAme\":\"Broom\",\"coSt\":32.5,\"TimEStamp\":\"2019-11-22T06:32:15\"}]"; - converter.Setup(_ => _.BuildItemFromAttributeAsync(arg)).ReturnsAsync(json); + converter.Setup(_ => _.BuildItemFromAttributeAsync(arg, ConvertType.IEnumerable)).ReturnsAsync(json); list = new List(); data = new TestData {